feat(events): persist last processed ledger for crash recovery (#80) - #189
Open
Fury03 wants to merge 1 commit into
Open
feat(events): persist last processed ledger for crash recovery (#80)#189Fury03 wants to merge 1 commit into
Fury03 wants to merge 1 commit into
Conversation
Persist the Stellar event poller's last processed ledger to the database after every poll so the service resumes from that checkpoint on restart instead of replaying from the chain tip and dropping events. - add EventPollerCheckpoint model + migration (event_poller_checkpoints) - EventsService loads the checkpoint on boot and resumes from it, with a guard that ignores a checkpoint pointing past the current chain tip - checkpoint is written in a finally block after each poll; write failures are logged and counted but never block polling - expose checkpoint state via GET /health/checkpoint and as an informational indicator in the aggregate GET /health check - specs cover resume, cold start, stale-checkpoint guard, post-poll writes, write-failure tolerance and the health routes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #80
Problem Statement (The Bug)
The Stellar event poller (
EventsService) only held its cursor — the lastprocessed ledger — in a private in-memory field:
On every process restart (deploy, crash, OOM kill, pod reschedule) the
cursor was reinitialised to
latestLedger - 10. Any contract event thatlanded on-chain while the service was down — or more than 10 ledgers before
it came back — was never polled and never processed. Enrollment
safety-net updates,
course_completed→ certificate flows,certificate_issuednotifications and status syncs were silently dropped.This cannot be fixed with a local patch because the failure is
architectural: the recovery point does not survive the thing it needs to
recover from. Widening the
- 10look-back only trades dropped events forduplicate reprocessing and still has no lower bound on downtime. The cursor
has to live in durable storage that outlives the process.
Solution Comparison and Decision
latest - 500)MAX(ledger)inchain_eventschain_eventsrows are only written for events we actually received; it tells you nothing about ledgers polled that produced no events, so the cursor still crawls backwards after a quiet outage. Also couples the cursor to a table that can be pruned.Option D is the only approach that makes the recovery point as durable as the
data it protects.
The Change
New model (
prisma/schema.prisma+ migration20260827000000_add_event_poller_checkpoint):A single row keyed by
stellar-event-poller.New core method —
persistCheckpoint()(best-effort, never throws):pollEvents()now writes the checkpoint in afinallyblock — after asuccessful batch, and also after an empty poll so the cursor keeps up during
quiet periods:
onModuleInit()resumes from the checkpoint, with a guard against astale/foreign checkpoint:
latest - 10(events lost)resumedFromCheckpoint = true)latest - 10latest - 10, initial checkpoint writtengetLatestLedgerfailsupsertpersisted ledger =max(event.ledger)+1upsertpersisted (keeps cursor fresh)GET /health/checkpoint+ indicator inGET /healthHealth endpoint (
GET /health/checkpoint):{ "key": "stellar-event-poller", "lastProcessedLedger": 5123, "persistedLedger": 5123, "lastPolledAt": "2026-08-27T12:00:00.000Z", "resumedFromCheckpoint": true, "consecutiveWriteFailures": 0, "lastWriteError": null, "healthy": true }The aggregate
GET /healthgains an informationalevent_poller_checkpointindicator. It is deliberately non-fatal (always
status: "up", carries acheckpointHealthyflag) so a transient checkpoint write error never returnsa 503 that pulls the whole API out of the load balancer.
Compatibility Note
There is no
INTERFACE_VERSION/ API version constant in this codebase, sonothing of that kind is bumped. Externally visible surface changes are
additive only:
event_poller_checkpoints(new migration, no existing table touched)GET /health/checkpointGET /healthresponseNo existing request or response shape changes.
EventsModulenowexportsEventsService(consumed byHealthModule); no behavioural change to theexisting
GET /eventsroute.Incidental Fixes
if (!events.length) return;meantlastProcessedLedgerwas only ever touched when events existed. It nowadvances/persists every cycle.
getLatestLedger()on boot used todrop straight to ledger
1(full-history replay). It now prefers thepersisted checkpoint and only falls back to
1when there is genuinely nostate.
Testing
New specs (all green):
src/modules/events/events.service.spec.tsresumes from the persisted ledger instead of the chain tipcold-starts near the chain tip and writes an initial checkpoint when none existsignores a checkpoint that points past the chain tip (stale / wrong network)advances and persists the ledger after processing eventsstill writes a checkpoint when a poll finds no eventsswallows the write error, records the failure and keeps advancing in memoryrecovers (failure counter resets) once a later write succeedsreports the persisted ledger from the databasesrc/modules/health/health.controller.spec.ts— resolves the controllerthrough a real
TerminusModulesocheck()runs the actualHealthCheckServiceaggregation andcheckpoint()runs the realGET /health/checkpointhandler:GET /health/checkpoint returns the current ledger checkpoint payloadGET /health includes the event poller checkpoint indicator and stays 200 (ok)GET /health surfaces a degraded checkpoint without failing the overall checkAdversarial case from the issue ("Manage checkpoint write failures without
blocking polling"):
upsertis mocked to reject;pollEvents()stillresolves,
consecutiveWriteFailuresbecomes1,healthybecomesfalse,and the in-memory cursor still advances to
111.Before: no checkpoint persistence —
FAILEDto survive restart.After:
ok— cursor reloaded from Postgres on boot.Full suite (
npx jest) is green except for pre-existing, unrelatedTypeScript failures in
src/modules/reviews/*andsrc/modules/uploads/video-transcode.service.ts(references toReview/ModerationLogmodels and a missingUploadResultexport that are not inmain's schema). Those modules are not touched by this PR.Additional Notes
mainbuilds the parts this PR touches.EventsServiceboot/poll flow, one new Prisma model +migration, and the health module. No changes to event handlers
(
handleCourseApproved,handleCertificateIssued, …), toStellarService, or to any other module.